1"use client";
2
3
15import { useState } from "react";
16import { useParams } from "next/navigation";
17import { Loader2, Mail, Trash2 } from "lucide-react";
18import { toast } from "@/stores/ui-store";
19import { useAuth } from "@/hooks/use-auth";
20import { useWorkspace, useWorkspaceMembers } from "@/hooks/use-workspace";
21import { useInviteMember, useRemoveMember, useUpdateMemberRole, ASSIGNABLE_ROLES, roleLabel } from "@/hooks/use-members";
22import { WorkspaceMember } from "@/lib/types";
23import { SettingsShell, Section, inputCls } from "@/components/settings/settings-shell";
24import { UserAvatar } from "@/components/database/cell-renderer";
25
26export default function SettingsMembersPage() {
27 const params = useParams<{ workspace: string }>();
28 const slugOrId = params?.workspace ?? "";
29 const { data: ws, isLoading } = useWorkspace(slugOrId);
30 const members = useWorkspaceMembers(ws?.id ?? "");
31 const updateRole = useUpdateMemberRole({ workspaceId: ws?.id ?? "" });
32 const remove = useRemoveMember({ workspaceId: ws?.id ?? "" });
33
34 const { user } = useAuth();
35 const [email, setEmail] = useState("");
36 const [inviteRole, setInviteRole] = useState<string>("MEMBER");
37 const invite = useInviteMember({ workspaceId: ws?.id ?? "" });
38
39 const role = (ws?.role ?? "").toLowerCase();
40 const canManage = role === "owner" || role === "admin";
41 const isOwner = role === "owner";
42
43 const submitInvite = () => {
44 if (!email.trim()) return;
45 invite.mutate(
46 { email: email.trim(), role: inviteRole as (typeof ASSIGNABLE_ROLES)[number] },
47 {
48 onSuccess: () => {
49 toast("success", "Invitation sent", email.trim());
50 setEmail("");
51 },
52 onError: (e) => toast("error", "Invite failed", (e as Error).message)
53 }
54 );
55 };
56
57 const changeRole = (userId: string, next: string) => {
58 updateRole.mutate(
59 { userId, role: next as (typeof ASSIGNABLE_ROLES)[number] },
60 {
61 onSuccess: () => toast("success", "Role updated"),
62 onError: (e) => toast("error", "Role change failed", (e as Error).message)
63 }
64 );
65 };
66
67 const removeMember = (m: WorkspaceMember) => {
68 if (!window.confirm(`Remove ${m.user.name} from this workspace?`)) return;
69 remove.mutate(m.userId, {
70 onSuccess: () => toast("success", "Member removed"),
71 onError: (e) => toast("error", "Remove failed", (e as Error).message)
72 });
73 };
74
75 return (
76 <SettingsShell title="Members" description={ws ? `${ws.name} · ${ws.memberCount} members` : "Manage who can access this workspace."} active="members">
77 {isLoading || !ws ? (
78 <p className="text-sm text-muted-foreground">Loading…</p>
79 ) : (
80 <div className="space-y-5">
81 {}
82 {canManage && (
83 <Section title="Invite">
84 <div className="flex items-center gap-1.5">
85 <input
86 type="email"
87 className={`min-w-0 flex-1 ${inputCls}`}
88 placeholder="name@example.com"
89 value={email}
90 onChange={(e) => setEmail(e.target.value)}
91 onKeyDown={(e) => e.key === "Enter" && submitInvite()}
92 />
93 <select className={`rounded-md border border-input bg-background px-1.5 py-1.5 text-sm outline-none`} value={inviteRole} onChange={(e) => setInviteRole(e.target.value)} aria-label="Role">
94 {ASSIGNABLE_ROLES.map((r) => (
95 <option key={r} value={r}>
96 {roleLabel(r)}
97 </option>
98 ))}
99 </select>
100 <button
101 type="button"
102 className="inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
103 disabled={!email.trim() || invite.isPending}
104 onClick={submitInvite}
105 >
106 {invite.isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Mail className="size-3.5" />}
107 Invite
108 </button>
109 </div>
110 <p className="mt-1 text-[11px] text-muted-foreground">
111 Invitations are emailed to the person. They become a member when they accept.
112 </p>
113 </Section>
114 )}
115
116 {}
117 <Section title={`Members (${members.data?.length ?? 0})`}>
118 {members.isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
119 <ul className="divide-y divide-border">
120 {(members.data ?? []).map((m) => {
121 const mRole = (m.role ?? "").toUpperCase();
122 const isSelf = m.userId === user?.id;
123
124
125 const canEditThis = canManage && mRole !== "OWNER" && (!isOwner || !isSelf);
126 return (
127 <li key={m.userId} className="flex items-center gap-2.5 py-2">
128 <UserAvatar user={{ id: m.user.id, name: m.user.name, avatarUrl: m.user.image ?? null }} size={28} />
129 <div className="min-w-0 flex-1">
130 <p className="truncate text-sm">
131 {m.user.name}
132 {isSelf ? <span className="ml-1 text-xs text-muted-foreground">(you)</span> : null}
133 </p>
134 <p className="truncate text-xs text-muted-foreground">{m.user.email}</p>
135 </div>
136 {canEditThis ? (
137 <select
138 className="rounded-md border border-input bg-background px-1.5 py-1 text-xs outline-none"
139 value={mRole}
140 disabled={updateRole.isPending}
141 onChange={(e) => changeRole(m.userId, e.target.value)}
142 aria-label={`Role for ${m.user.name}`}
143 >
144 {ASSIGNABLE_ROLES.map((r) => (
145 <option key={r} value={r}>
146 {roleLabel(r)}
147 </option>
148 ))}
149 </select>
150 ) : (
151 <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{roleLabel(mRole)}</span>
152 )}
153 {canEditThis && (
154 <button
155 type="button"
156 className="rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
157 onClick={() => removeMember(m)}
158 disabled={remove.isPending}
159 aria-label={`Remove ${m.user.name}`}
160 >
161 <Trash2 className="size-4" />
162 </button>
163 )}
164 </li>
165 );
166 })}
167 </ul>
168 </Section>
169 </div>
170 )}
171 </SettingsShell>
172 );
173}
174